agentmux_srv\backend\storage/
agents.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent subsystem — definitions, instances, and their lifecycle CRUD.
5//!
6//! Covers `agent_def_*` methods (template + user-clone definitions),
7//! `instance_*` methods (per-launch instance rows, named-agent
8//! continuation, identity-bound active-for-block resolution), the
9//! `AgentDefinition` / `AgentInstance` structs, and the `InstanceStatus` enum.
10//! `db_agents` is the authoritative consolidated read table; `db_agent_definitions`
11//! and `db_agent_instances` remain the write targets with dual-write mirrors.
12
13use rusqlite::params;
14use serde::{Deserialize, Serialize};
15
16use super::error::StoreError;
17use super::store::Store;
18
19/// A user-defined AI agent in the user's agent-definition catalog.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AgentDefinition {
22    pub id: String,
23    /// Stable, filesystem-safe identifier. Drives working directory,
24    /// env var keys (AGENTMUX_AGENT_ID), and cross-references.
25    /// NEVER changes after creation — distinct from `name` which is
26    /// the renameable display. See
27    /// specs/SPEC_AGENT_IDENTITY_RESTRUCTURE_2026_04_14.md.
28    #[serde(default)]
29    pub slug: String,
30    pub name: String,
31    pub icon: String,
32    pub provider: String,
33    pub description: String,
34    #[serde(default)]
35    pub working_directory: String,
36    #[serde(default)]
37    pub shell: String,
38    #[serde(default)]
39    pub provider_flags: String,
40    #[serde(default)]
41    pub auto_start: i64,
42    #[serde(default)]
43    pub restart_on_crash: i64,
44    #[serde(default)]
45    pub idle_timeout_minutes: i64,
46    pub created_at: i64,
47    #[serde(default = "default_agent_type")]
48    pub agent_type: String,
49    #[serde(default)]
50    pub environment: String,
51    #[serde(default)]
52    pub agent_bus_id: String,
53    #[serde(default)]
54    pub is_seeded: i64,
55    /// JSON-encoded per-provider account assignments
56    /// (`{"github":"acct-id", …}`). Written by the Agent pane's Identity
57    /// tab (`AgentIdentityPanel`) via `updateagent`, read back by
58    /// `parseAgentAccounts` and consumed by startup credential
59    /// resolution. (An older v6 comment called this deprecated in favour
60    /// of `db_agent_identity_links`; that migration never completed — the
61    /// JSON blob is still the live store, so the schema flatten keeps the
62    /// column.)
63    #[serde(default)]
64    pub accounts: String,
65    /// Parent definition id (db_agent_definitions.id). Empty string = root
66    /// definition; non-empty = this agent was forked from another.
67    /// Added in v6. See spec §Phase 1.
68    #[serde(default)]
69    pub parent_id: String,
70    /// Label describing the branch (e.g. `"pr-422-review"`,
71    /// `"experiment-refactor"`). Empty for root definitions and for
72    /// branches that didn't set a label. Added in v6.
73    #[serde(default)]
74    pub branch_label: String,
75    /// Last-modified timestamp (epoch ms). Set to `created_at` on insert
76    /// and refreshed on every `agent_def_update`. Schema v2. `0` for
77    /// rows written before v2 (until next update).
78    #[serde(default)]
79    pub updated_at: i64,
80    /// Per-user hide flag for seeded templates. `1` = the user clicked
81    /// "Hide template" on the picker's `+ New from template` tier; the
82    /// row stays on disk (templates are manifest-managed; deletion would
83    /// fight re-seed) but the default `listagents` view filters it out.
84    /// Reset to `0` by the agent-seed re-sync flow for any NEW template
85    /// id newly added to the manifest, so a fresh template surfaces once
86    /// even if a same-named one was previously hidden. Schema v3 (Phase
87    /// 2 of `SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md` — Q2 Decision Y).
88    /// User-owned rows (`is_seeded = 0`) MUST stay at `0` here; their
89    /// removal path is `deleteagent`, not hide.
90    #[serde(default)]
91    pub user_hidden: i64,
92    /// Docker image to use when `agent_type == "container"`.
93    /// e.g. `"ghcr.io/agentmuxai/agent-claude:latest"`.
94    /// Empty string for host agents. Schema v6.
95    #[serde(default)]
96    pub container_image: String,
97    /// JSON array of volume mount specs for container agents.
98    /// Each element is a string in Docker bind-mount format:
99    /// `"source:target"` or `"source:target:options"`.
100    /// Empty JSON array (`"[]"`) for host agents. Schema v6.
101    #[serde(default = "default_container_volumes")]
102    pub container_volumes: String,
103    /// Stable Docker container name managed by the server.
104    /// Set to `"agentmux-<slug>"` on first container spawn;
105    /// empty for host agents. Schema v6.
106    #[serde(default)]
107    pub container_name: String,
108}
109
110/// Derive a filesystem-safe slug from a display name. Lowercase,
111/// ASCII alphanumeric + dash/underscore, consecutive dashes collapsed,
112/// trimmed to 64 chars. Returns `"agent"` if the input has no valid
113/// characters (defensive fallback).
114pub fn derive_slug(name: &str) -> String {
115    let filtered: String = name
116        .to_lowercase()
117        .chars()
118        .map(|c| {
119            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
120                c
121            } else {
122                '-'
123            }
124        })
125        .collect();
126    let collapsed: String = filtered
127        .split('-')
128        .filter(|s| !s.is_empty())
129        .collect::<Vec<_>>()
130        .join("-");
131    let trimmed: String = collapsed.chars().take(64).collect();
132    if trimmed.is_empty() {
133        "agent".to_string()
134    } else {
135        trimmed
136    }
137}
138
139fn default_agent_type() -> String {
140    "standalone".to_string()
141}
142
143fn default_container_volumes() -> String {
144    "[]".to_string()
145}
146
147/// Instance lifecycle status. Serialised lowercase to match the DB text.
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "lowercase")]
150pub enum InstanceStatus {
151    Running,
152    Paused,
153    Stopped,
154    Crashed,
155    Detached,
156}
157
158impl InstanceStatus {
159    pub fn as_str(&self) -> &'static str {
160        match self {
161            Self::Running => "running",
162            Self::Paused => "paused",
163            Self::Stopped => "stopped",
164            Self::Crashed => "crashed",
165            Self::Detached => "detached",
166        }
167    }
168    pub fn parse(s: &str) -> Option<Self> {
169        match s {
170            "running" => Some(Self::Running),
171            "paused" => Some(Self::Paused),
172            "stopped" => Some(Self::Stopped),
173            "crashed" => Some(Self::Crashed),
174            "detached" => Some(Self::Detached),
175            _ => None,
176        }
177    }
178}
179
180/// Optional context describing which GitHub-side unit of work a specific
181/// instance is operating on. Stored as JSON in
182/// `db_agent_instances.github_context` (empty string when unset).
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct GitHubContext {
185    pub repo: String, // "owner/repo"
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub pr_number: Option<u32>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub branch: Option<String>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub issue_number: Option<u32>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub workflow_run_id: Option<u64>,
194}
195
196/// Partial-update payload for [`Store::instance_update_partial`]. Each
197/// `Some` field is written; `None` leaves the column untouched. Mirrors
198/// the mutable subset of `CommandUpdateAgentInstanceData`
199/// (`block_id`/`session_id`/`status`/`github_context`/`ended_at`) — the
200/// only columns `instance_update` ever wrote. `Some("")` for a string
201/// field explicitly clears it.
202#[derive(Debug, Clone, Default)]
203pub struct InstanceUpdate {
204    pub block_id: Option<String>,
205    pub session_id: Option<String>,
206    pub status: Option<String>,
207    pub github_context: Option<String>,
208    pub ended_at: Option<i64>,
209}
210
211/// One row per running/historical execution of an agent definition.
212/// `block_id` / `session_id` / `github_context` are modelled as empty
213/// strings on the wire rather than `Option<String>` to match the
214/// existing schema conventions (`NOT NULL DEFAULT ''`). Callers
215/// that need structured absence can use `.is_empty()`.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AgentInstance {
218    pub id: String,
219    pub definition_id: String,
220    #[serde(default)]
221    pub parent_instance_id: String,
222    #[serde(default)]
223    pub block_id: String,
224    #[serde(default)]
225    pub session_id: String,
226    pub status: String,
227    /// JSON-encoded `GitHubContext`, or empty string.
228    #[serde(default)]
229    pub github_context: String,
230    pub started_at: i64,
231    #[serde(default)]
232    pub ended_at: i64,
233    pub created_at: i64,
234    /// FK to `db_identity_bundles.id`. Empty string means "use the blank
235    /// singleton" (= ambient creds, no env-var injection). Set at
236    /// instantiation via the launch modal's Identity dropdown.
237    #[serde(default)]
238    pub identity_id: String,
239    /// FK to `db_memory_bundles.id`. Empty string means "use the blank
240    /// singleton" (= vanilla CLI, no instructions). Set at
241    /// instantiation via the launch modal's Memory dropdown.
242    #[serde(default)]
243    pub memory_id: String,
244    /// User-chosen instance name (becomes `AGENTMUX_AGENT_ID` in the
245    /// spawn env). Empty for pre-v8 rows and for un-named launches.
246    /// Drives the "Continue agent" dropdown in the launch modal.
247    #[serde(default)]
248    pub instance_name: String,
249    /// Absolute path returned by `allocate_agent_workdir` at spawn.
250    /// Stored explicitly (rather than re-derived from the slug at
251    /// continue-time) so the continue flow is robust against
252    /// slug-rule changes and user-side renames.
253    #[serde(default)]
254    pub working_directory: String,
255    /// Soft-delete flag for the "Forget agent" affordance. Hidden
256    /// rows stay on disk for audit + recovery.
257    #[serde(default)]
258    pub display_hidden: bool,
259}
260
261impl Store {
262    /// List all agent definitions, **most-recently-used first**.
263    ///
264    /// Reads from the consolidated `db_agents` table, ordered by
265    /// `updated_at DESC` then `created_at ASC`. Dual-write keeps
266    /// `db_agents.updated_at` fresh on every definition mutation AND every
267    /// instance lifecycle touch, so recency on a row tracks the last time
268    /// the agent was either edited or launched.
269    ///
270    /// Result-set shape: every `db_agents` row is returned — templates
271    /// (`is_template = 1`) and user-clone projections (`is_template = 0`)
272    /// each appear once. `parent_id` is sourced from
273    /// `db_agents.parent_template_id`.
274    ///
275    /// Find user-clone definitions for a given seeded template (rows
276    /// in `db_agent_definitions` with `is_seeded = 0` and
277    /// `parent_id = <template.id>`). Returns the most-recent-first.
278    ///
279    /// Reads `db_agent_definitions` directly — NOT the `db_agents`
280    /// consolidated view — because the latter surfaces template-
281    /// instance projection rows under the same
282    /// `is_template = 0 AND parent_template_id = <tpl>` shape as
283    /// user-clone defs, which would conflate two distinct things.
284    ///
285    /// Sole production caller today is the `template_promote`
286    /// migration's "did the user delete the deterministic-id
287    /// clone?" diagnostic logging and its tests. Kept public so
288    /// follow-up callers (e.g. a cleanup pass that GCs orphaned
289    /// pre-deterministic-id clones from earlier migration code)
290    /// can use it without re-deriving the schema.
291    pub fn user_clone_defs_for_template(
292        &self,
293        template_id: &str,
294    ) -> Result<Vec<AgentDefinition>, StoreError> {
295        let conn = self.conn.lock().unwrap();
296        let mut stmt = conn.prepare(
297            "SELECT id, slug, name, icon, provider, description,
298                    working_directory, shell, provider_flags, auto_start,
299                    restart_on_crash, idle_timeout_minutes, created_at,
300                    agent_type, environment, agent_bus_id, is_seeded,
301                    accounts, parent_id, branch_label, updated_at,
302                    user_hidden, container_image, container_volumes, container_name
303             FROM db_agent_definitions
304             WHERE is_seeded = 0 AND parent_id = ?1
305             ORDER BY updated_at DESC, created_at DESC",
306        )?;
307        let rows = stmt.query_map(params![template_id], map_agent_definition_row)?;
308        let mut out = Vec::new();
309        for r in rows {
310            out.push(r?);
311        }
312        Ok(out)
313    }
314
315    /// Fetch a single agent definition by primary key. Reads
316    /// `db_agent_definitions` directly (not the `db_agents`
317    /// consolidated view that `agent_def_list` reads), so it
318    /// returns user-clone definitions and seeded templates, never
319    /// template-instance projection rows.
320    ///
321    /// Used by the `template_promote` migration's deterministic-id
322    /// idempotency check (see
323    /// `migrate_promote_template_sessions_v1`): every retry asks
324    /// "does the promote-target clone for this template already
325    /// exist?" and either reuses it or inserts it.
326    pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
327        // Try local SQLite first (conn released before global registry check).
328        let local = {
329            let conn = self.conn.lock().unwrap();
330            let mut stmt = conn.prepare(
331                "SELECT id, slug, name, icon, provider, description,
332                        working_directory, shell, provider_flags, auto_start,
333                        restart_on_crash, idle_timeout_minutes, created_at,
334                        agent_type, environment, agent_bus_id, is_seeded,
335                        accounts, parent_id, branch_label, updated_at,
336                        user_hidden, container_image, container_volumes, container_name
337                 FROM db_agent_definitions
338                 WHERE id = ?1",
339            )?;
340            let mut rows = stmt.query_map(params![id], map_agent_definition_row)?;
341            match rows.next() {
342                Some(row) => Some(row?),
343                None => None,
344            }
345        }; // MutexGuard dropped here.
346        if local.is_some() {
347            return Ok(local);
348        }
349        // Not in local SQLite — check the global cross-channel registry.
350        // agent_def_list() already overlays this; keep agent_def_get consistent.
351        let Some(reg) = self.shared_def_registry() else {
352            return Ok(None);
353        };
354        match reg.get(id) {
355            Ok(Some(record)) => Ok(Some(super::def_registry_mirror::record_to_agent_definition(&record))),
356            Ok(None) => Ok(None),
357            Err(e) => {
358                tracing::warn!(agent_id = %id, error = %e, "agent_def_get: global registry lookup failed; returning not-found");
359                Ok(None)
360            }
361        }
362    }
363
364    pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
365        // Local SQLite: this channel's templates (seeded) + its own user agents.
366        let local: Vec<AgentDefinition> = {
367            let conn = self.conn.lock().unwrap();
368            let mut stmt = conn.prepare(
369                "SELECT id, slug, name, icon, provider, description,
370                        working_directory, shell, provider_flags, auto_start,
371                        restart_on_crash, idle_timeout_minutes, created_at,
372                        agent_type, environment, agent_bus_id, is_seeded,
373                        accounts, parent_template_id, branch_label, updated_at,
374                        user_hidden, container_image, container_volumes, container_name
375                 FROM db_agents
376                 ORDER BY updated_at DESC, created_at ASC",
377            )?;
378            let rows = stmt.query_map([], map_agent_definition_row)?;
379            let mut agents = Vec::new();
380            for row in rows {
381                agents.push(row?);
382            }
383            agents
384        };
385        // conn dropped. Overlay the GLOBAL cross-channel user-agent roster so
386        // an agent created in another channel appears here too. The global
387        // store wins for user rows (it's authoritative and holds cross-channel
388        // agents); templates (seeded — never in the global store) come from
389        // local SQLite. Falls back to SQLite-only when the global store is
390        // absent or unreadable. (P0.2c.)
391        let Some(reg) = self.shared_def_registry() else {
392            return Ok(local);
393        };
394        let global = match reg.list_active() {
395            Ok(recs) => recs,
396            Err(e) => {
397                tracing::warn!(error = %e, "def registry: global list failed, using SQLite only");
398                return Ok(local);
399            }
400        };
401        let mut by_id: std::collections::HashMap<String, AgentDefinition> =
402            local.into_iter().map(|d| (d.id.clone(), d)).collect();
403        for rec in &global {
404            let def = super::def_registry_mirror::record_to_agent_definition(rec);
405            by_id.insert(def.id.clone(), def);
406        }
407        let mut result: Vec<AgentDefinition> = by_id.into_values().collect();
408        // Match the SQL ORDER BY: updated_at DESC, then created_at ASC.
409        result.sort_by(|a, b| {
410            b.updated_at
411                .cmp(&a.updated_at)
412                .then(a.created_at.cmp(&b.created_at))
413        });
414        Ok(result)
415    }
416
417    /// Count agent rows (used by seed engine to check if seeding is needed).
418    /// Reads from the consolidated `db_agents` table. The seed engine only
419    /// cares about `== 0` to decide "fresh database, seed templates".
420    pub fn agent_def_count(&self) -> Result<i64, StoreError> {
421        let conn = self.conn.lock().unwrap();
422        let count: i64 = conn.query_row(
423            "SELECT COUNT(*) FROM db_agents",
424            [],
425            |row| row.get(0),
426        )?;
427        Ok(count)
428    }
429
430    /// Whether a definition with `id` exists in the LOCAL channel's SQLite
431    /// (`db_agents`). Gates the cross-channel content/skills fallback: a
432    /// locally-known agent with genuinely empty content/skills must NOT
433    /// resurrect them from the global record. (reagent P1 on #1385.)
434    pub(super) fn agent_def_exists_local(&self, id: &str) -> Result<bool, StoreError> {
435        let conn = self.conn.lock().unwrap();
436        let exists: bool = conn.query_row(
437            "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
438            params![id],
439            |row| row.get(0),
440        )?;
441        Ok(exists)
442    }
443
444    /// Delete all seeded agents (is_seeded=1). Used by reseed to clear built-in agents.
445    pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
446        // Reagent P1 round 4 on #1013: capture the cascaded instance ids
447        // BEFORE the FK cascade fires. The bulk delete on
448        // `db_agent_definitions` triggers `ON DELETE CASCADE` on
449        // `db_agent_instances` for every instance keyed off those
450        // templates; once the cascade runs, we can't query them anymore,
451        // and `db_agents` would be left holding orphaned instance
452        // projections. Capture → delete → drop projections.
453        let (rows, cascaded_inst_ids) = {
454            let conn = self.conn.lock().unwrap();
455            let mut stmt = conn.prepare(
456                "SELECT i.id FROM db_agent_instances i
457                 INNER JOIN db_agent_definitions d ON i.definition_id = d.id
458                 WHERE d.is_seeded = 1",
459            )?;
460            let ids: Vec<String> = stmt
461                .query_map([], |r| r.get::<_, String>(0))?
462                .collect::<Result<Vec<_>, _>>()?;
463            drop(stmt);
464            let rows = conn.execute("DELETE FROM db_agent_definitions WHERE is_seeded=1", [])?;
465            (rows, ids)
466        };
467        // Drop template projections AND any cascaded instance projections from
468        // db_agents. User-clone definition projections (`is_template = 0`, `id`
469        // is a def_id) are NOT touched here — they persist with the def row.
470        self.agents_dual_write_seeded_delete(&cascaded_inst_ids)?;
471        Ok(rows)
472    }
473
474    /// Insert a new agent definition. Auto-derives slug from name if empty,
475    /// resolves collisions by appending `-2`, `-3`, etc., and mutates
476    /// `agent.slug` so the caller sees the resolved value (important
477    /// for handlers that serialize the struct back to the frontend
478    /// after insert).
479    ///
480    /// The collision check + insert run under a single mutex lock,
481    /// so this is race-safe against concurrent inserts on the same
482    /// connection.
483    pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
484        let conn = self.conn.lock().unwrap();
485        let base = if agent.slug.is_empty() {
486            derive_slug(&agent.name)
487        } else {
488            agent.slug.clone()
489        };
490        // Collision-resolve: scan for existing slugs matching base or base-N.
491        // Reads uniqueness from `db_agents` — the consolidated table surfaces
492        // both definition slugs and template-instance projections, so a slug
493        // collision against an instance-derived row is caught here too.
494        let mut candidate = base.clone();
495        let mut n: u32 = 2;
496        loop {
497            let count: i64 = conn.query_row(
498                "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
499                params![candidate],
500                |row| row.get(0),
501            )?;
502            if count == 0 {
503                break;
504            }
505            candidate = format!("{}-{}", base, n);
506            n += 1;
507        }
508        agent.slug = candidate;
509        conn.execute(
510            "INSERT INTO db_agent_definitions (id, slug, name, icon, provider, description,
511             working_directory, shell, provider_flags, auto_start, restart_on_crash,
512             idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
513             is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden,
514             container_image, container_volumes, container_name)
515             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
516                     ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
517            params![
518                agent.id,
519                agent.slug,
520                agent.name,
521                agent.icon,
522                agent.provider,
523                agent.description,
524                agent.working_directory,
525                agent.shell,
526                agent.provider_flags,
527                agent.auto_start,
528                agent.restart_on_crash,
529                agent.idle_timeout_minutes,
530                agent.created_at,
531                agent.agent_type,
532                agent.environment,
533                agent.agent_bus_id,
534                agent.is_seeded,
535                agent.accounts,
536                agent.parent_id,
537                agent.branch_label,
538                // New definitions: updated_at == created_at.
539                agent.created_at,
540                // Phase 2 (hide templates): new rows start visible. The
541                // user only hides via the explicit `agent_def_hide` RPC,
542                // and the agent-seed re-sync forces user_hidden = 0 on
543                // any newly-added template id anyway, so honouring the
544                // caller-supplied value here is safe even when a stray
545                // 1 sneaks through.
546                agent.user_hidden,
547                agent.container_image,
548                agent.container_volumes,
549                agent.container_name,
550            ],
551        )?;
552        // Persist the stamped updated_at before we leave the lock so the
553        // dual-write helper sees the same value the SQL row carries.
554        let stamped_updated_at = agent.created_at;
555        drop(conn);
556        let mut snapshot = agent.clone();
557        snapshot.updated_at = stamped_updated_at;
558        // Mirror new definition into db_agents immediately.
559        self.agents_dual_write_definition_upsert(&snapshot)?;
560        // Mirror into the global cross-channel definition store. Content +
561        // skills are inserted after the definition (separate calls), so this
562        // initial record is content-less; agent_content_set / agent_skill_*
563        // re-mirror with the full payload. (P0.2b.)
564        self.registry_def_upsert(&agent.id);
565        // Reagent P1 on #1013 round 2: do NOT mutate the caller's
566        // `&mut AgentDefinition` here. The PR is supposed to be
567        // zero-behaviour-change; the previous version reflected
568        // `stamped_updated_at` back onto the caller, which is an
569        // observable mutation downstream callers may rely on remaining
570        // untouched. Callers that need the freshly-stamped value can
571        // re-fetch the row via the normal read path.
572        let _ = stamped_updated_at;
573        Ok(())
574    }
575
576    /// Atomic check-then-insert for `agent.define`.
577    ///
578    /// Looks up an existing user-owned definition by name (case-insensitive)
579    /// or derived slug under the SAME mutex guard that protects the INSERT —
580    /// preventing TOCTOU when two concurrent `agent.define` calls arrive for
581    /// the same name.
582    ///
583    /// Returns:
584    /// - `Ok(Some(def))` — an existing row matched; `agent` was NOT inserted.
585    /// - `Ok(None)` — no match; `agent` was inserted and `agent.slug` now
586    ///   holds the collision-resolved slug.
587    pub fn agent_def_find_or_insert(
588        &self,
589        agent: &mut AgentDefinition,
590    ) -> Result<Option<AgentDefinition>, StoreError> {
591        let name_lower = agent.name.trim().to_lowercase();
592        let derived_slug = derive_slug(agent.name.trim());
593
594        let stamped_updated_at = {
595            let conn = self.conn.lock().unwrap();
596
597            // Check under the same lock to close the TOCTOU window.
598            let mut stmt = conn.prepare(
599                "SELECT id, slug, name, icon, provider, description,
600                        working_directory, shell, provider_flags, auto_start,
601                        restart_on_crash, idle_timeout_minutes, created_at,
602                        agent_type, environment, agent_bus_id, is_seeded,
603                        accounts, parent_id, branch_label, updated_at,
604                        user_hidden, container_image, container_volumes, container_name
605                 FROM db_agent_definitions
606                 WHERE (lower(trim(name)) = ?1 OR slug = ?2)
607                   AND is_seeded = 0
608                 ORDER BY CASE WHEN lower(trim(name)) = ?1 THEN 0 ELSE 1 END
609                 LIMIT 1",
610            )?;
611            let mut rows = stmt.query_map(
612                params![name_lower, derived_slug],
613                map_agent_definition_row,
614            )?;
615            if let Some(row) = rows.next() {
616                return Ok(Some(row?));
617            }
618            // Drop borrows on `conn` before proceeding to the insert.
619            drop(rows);
620            drop(stmt);
621
622            // Not found — insert under the same lock.
623            let base = if agent.slug.is_empty() {
624                derive_slug(&agent.name)
625            } else {
626                agent.slug.clone()
627            };
628            let mut candidate = base.clone();
629            let mut n: u32 = 2;
630            // Query db_agents (the superset view) for slug uniqueness — matches
631            // agent_def_insert at line 440. Template-instance projections add rows to
632            // db_agents without a corresponding db_agent_definitions entry; checking
633            // only db_agent_definitions would miss those slugs and allow collisions.
634            loop {
635                let count: i64 = conn.query_row(
636                    "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
637                    params![candidate],
638                    |row| row.get(0),
639                )?;
640                if count == 0 {
641                    break;
642                }
643                candidate = format!("{}-{}", base, n);
644                n += 1;
645            }
646            agent.slug = candidate;
647            conn.execute(
648                "INSERT INTO db_agent_definitions
649                   (id, slug, name, icon, provider, description,
650                    working_directory, shell, provider_flags, auto_start, restart_on_crash,
651                    idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
652                    is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden,
653                    container_image, container_volumes, container_name)
654                 VALUES
655                   (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
656                    ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
657                params![
658                    agent.id, agent.slug, agent.name, agent.icon, agent.provider,
659                    agent.description, agent.working_directory, agent.shell,
660                    agent.provider_flags, agent.auto_start, agent.restart_on_crash,
661                    agent.idle_timeout_minutes, agent.created_at, agent.agent_type,
662                    agent.environment, agent.agent_bus_id, agent.is_seeded,
663                    agent.accounts, agent.parent_id, agent.branch_label,
664                    agent.created_at, // updated_at = created_at for new rows
665                    agent.user_hidden,
666                    agent.container_image, agent.container_volumes, agent.container_name,
667                ],
668            )?;
669            agent.created_at
670            // conn guard drops here; dual-write acquires the lock again below
671        };
672
673        let mut snapshot = agent.clone();
674        snapshot.updated_at = stamped_updated_at;
675        self.agents_dual_write_definition_upsert(&snapshot)?;
676        // Mirror the freshly-defined agent into the global store (agent.define
677        // path). Without this a define-created agent stays channel-local until
678        // a later edit. (codex P2 on #1385.)
679        self.registry_def_upsert(&agent.id);
680
681        Ok(None)
682    }
683
684    /// Set the `user_hidden` flag on a single agent definition. Phase 2
685    /// of the two-tier picker (Q2 Decision Y). Returns:
686    ///   `Ok(true)`  — row updated.
687    ///   `Ok(false)` — no row with that id exists.
688    ///   `Err(...)`  — the row exists but is NOT a seeded template
689    ///                 (`is_seeded != 1`). User-owned definitions go
690    ///                 through `agent_def_delete`, not hide.
691    ///
692    /// Does NOT bump `updated_at`: hide is a per-user view-state flag,
693    /// not a definition-content edit. Keeps `updated_at` faithful to the
694    /// agent's payload (the manifest re-sync compares `description` etc.
695    /// against the canonical row).
696    pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
697        let conn = self.conn.lock().unwrap();
698        // Only seeded templates (`is_template = 1`) may flip the hide flag.
699        // Templates carry `is_template = 1` and are the only rows allowed
700        // to flip the hide flag; folded user-clone-def projections and
701        // template-instance projections (both `is_template = 0`) reject.
702        let is_template: i64 = match conn.query_row(
703            "SELECT is_template FROM db_agents WHERE id = ?1",
704            params![id],
705            |row| row.get(0),
706        ) {
707            Ok(v) => v,
708            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
709            Err(e) => return Err(StoreError::Sqlite(e)),
710        };
711        if is_template != 1 {
712            return Err(StoreError::Other(format!(
713                "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
714                 user-owned definitions must use delete/archive paths, not hide"
715            )));
716        }
717        // The hide flag is per-row; the write must still hit the legacy
718        // table (cascade source) — dual-write mirrors into `db_agents`
719        // for the next read. (Templates are seeded; they do NOT go to the
720        // global cross-channel def store, so no mirror here.)
721        let rows = conn.execute(
722            "UPDATE db_agent_definitions SET user_hidden = ?1 WHERE id = ?2",
723            params![if hidden { 1_i64 } else { 0_i64 }, id],
724        )?;
725        if rows > 0 {
726            // Mirror the flag into `db_agents` so the next read of the
727            // template projection sees the update without waiting for a
728            // round-trip through definition_upsert.
729            if let Err(e) = conn.execute(
730                "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
731                params![if hidden { 1_i64 } else { 0_i64 }, id],
732            ) {
733                tracing::error!(
734                    id = %id,
735                    hidden,
736                    error = %e,
737                    "db_agents dual-write: template hide flag mirror failed",
738                );
739            }
740        }
741        Ok(rows > 0)
742    }
743
744    /// Update an existing agent definition (all fields except id, created_at, is_seeded).
745    /// `parent_id` and `branch_label` are NOT updatable post-insert — they
746    /// describe the agent's provenance; renaming or re-branching is done by
747    /// creating a new fork, not mutating the original.
748    ///
749    /// Self-stamps `updated_at` with the current time and writes it back into
750    /// `agent.updated_at`, so the caller's struct (e.g. an RPC response body)
751    /// reflects exactly what landed in the database.
752    pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
753        let now = std::time::SystemTime::now()
754            .duration_since(std::time::UNIX_EPOCH)
755            .unwrap_or_default()
756            .as_millis() as i64;
757        let rows = {
758            let conn = self.conn.lock().unwrap();
759            conn.execute(
760                "UPDATE db_agent_definitions SET name=?1, icon=?2, provider=?3, description=?4,
761                 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
762                 restart_on_crash=?9, idle_timeout_minutes=?10,
763                 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15,
764                 container_image=?17, container_volumes=?18, container_name=?19
765                 WHERE id=?16",
766                params![
767                    agent.name,
768                    agent.icon,
769                    agent.provider,
770                    agent.description,
771                    agent.working_directory,
772                    agent.shell,
773                    agent.provider_flags,
774                    agent.auto_start,
775                    agent.restart_on_crash,
776                    agent.idle_timeout_minutes,
777                    agent.agent_type,
778                    agent.environment,
779                    agent.agent_bus_id,
780                    agent.accounts,
781                    now,
782                    agent.id,
783                    agent.container_image,
784                    agent.container_volumes,
785                    agent.container_name,
786                ],
787            )?
788        };
789        // Reflect the persisted timestamp back to the caller's struct so an
790        // RPC response carries the fresh value, not the pre-update one.
791        agent.updated_at = now;
792        // Mirror updated definition into db_agents.
793        if rows > 0 {
794            self.agents_dual_write_definition_upsert(agent)?;
795            // Mirror the updated definition into the global store. (P0.2b.)
796            self.registry_def_upsert(&agent.id);
797        }
798        // Cross-channel edit: an agent surfaced only via the global overlay has
799        // no local SQLite row, so the UPDATE affected 0 rows. Apply the edit to
800        // the global record directly (preserving its content/skills) so editing
801        // a cross-channel agent isn't silently dropped with a "not found" error
802        // — symmetric with the unconditional cross-channel delete. (reagent P1
803        // on #1385.)
804        let updated_global = if rows == 0 {
805            self.registry_def_update_definition_fields(agent)
806        } else {
807            false
808        };
809        Ok(rows > 0 || updated_global)
810    }
811
812    /// Delete a agent definition by id. Returns true if a row was deleted.
813    pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
814        // Snapshot cascaded instance ids AND issue the parent DELETE
815        // under one lock acquisition so no thread can `instance_create`
816        // a new row for this definition between the two steps. The
817        // SQL DELETE's FK cascade fires inside SQLite while we hold
818        // the mutex, so the snapshot exactly matches the rows that
819        // got removed by the cascade.
820        let (cascaded_instance_ids, rows) = {
821            let conn = self.conn.lock().unwrap();
822            let cascaded_instance_ids: Vec<String> = {
823                let mut stmt = conn
824                    .prepare("SELECT id FROM db_agent_instances WHERE definition_id = ?1")?;
825                let iter = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
826                iter.collect::<Result<Vec<_>, _>>()?
827            };
828            let rows = conn.execute(
829                "DELETE FROM db_agent_definitions WHERE id=?1",
830                params![id],
831            )?;
832            (cascaded_instance_ids, rows)
833        };
834        if rows > 0 {
835            if let Some(reg) = self.registry() {
836                for instance_id in &cascaded_instance_ids {
837                    if let Err(e) = reg.hard_delete(instance_id) {
838                        tracing::warn!(
839                            instance_id = %instance_id,
840                            agent_def_id = %id,
841                            error = %e,
842                            "registry: failed to mirror agent_def_delete cascade"
843                        );
844                    }
845                }
846            }
847            // Mirror deleted definition out of db_agents.
848            self.agents_dual_write_definition_delete(id)?;
849            for instance_id in &cascaded_instance_ids {
850                self.agents_dual_write_instance_delete(instance_id)?;
851            }
852        }
853        // Tombstone the global definition record so another channel's stale
854        // SQLite can't resurrect this deleted user agent — AND so an agent
855        // that exists in THIS channel only via the global overlay (no local
856        // SQLite row, rows == 0) is actually deletable instead of reappearing
857        // on the next agent_def_list. (P0.2b + codex P1 on #1385.)
858        let global_retired = self.registry_def_retire(id);
859        Ok(rows > 0 || global_retired)
860    }
861
862    // AgentContent / AgentSkill / AgentHistory CRUD live in
863    // `super::content` / `super::skills` / `super::history` —
864    // each adds an `impl Store {}` block to this type.
865}
866
867impl Store {
868
869    // ---- Agent instance CRUD ----
870
871    /// List instances. Both filters are optional — pass `None` to scan
872    /// all instances. Ordered by `updated_at` descending, with
873    /// `created_at` as a tiebreaker (most recent activity first; the
874    /// dual-write bumps `updated_at` on every launch / continuation,
875    /// so a continued older agent ranks ahead of a brand-new untouched
876    /// one).
877    ///
878    /// Reads from the consolidated `db_agents` table (`is_template = 0`,
879    /// `user_hidden = 0`) for the no-status case.
880    /// Continuation chains pre-collapse — one row per logical agent.
881    /// The `definition_id` filter, when supplied, matches the agent's
882    /// own `id` only (templates aren't agents and user-clones derived
883    /// from a template are SEPARATE agents — see the implementation
884    /// note below for why `parent_template_id` traversal was dropped).
885    ///
886    /// Field mapping for fields with no consolidated-row analog:
887    /// - `block_id`, `session_id`, `status`, `ended_at`,
888    ///   `parent_instance_id` → type defaults (`""` / `0`). Truly
889    ///   transient per-launch state; not modelled on `db_agents`.
890    /// - `started_at` → `db_agents.created_at`. Same proxy used by
891    ///   `instance_get_by_name` (3b.2); the consolidated row's
892    ///   creation IS the agent's launch moment in the new model.
893    /// - `display_hidden` → always `false`. The WHERE clause filters
894    ///   `user_hidden = 0`, so hidden rows never surface here.
895    ///
896    /// Callers passing a
897    /// `status` filter need transient runtime state that `db_agents`
898    /// doesn't model. Route those to the legacy `db_agent_instances`
899    /// path so existing semantics are preserved until the
900    /// updateagentinstance handler's "fetch + merge transient fields"
901    /// pattern is refactored. (Currently no production caller passes
902    /// `status` — `listagentinstances` RPC frontends call with empty
903    /// filters — so the legacy path is exercised only by tests.)
904    /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.3.
905    pub fn instance_list(
906        &self,
907        definition_id: Option<&str>,
908        status: Option<&str>,
909    ) -> Result<Vec<AgentInstance>, StoreError> {
910        if status.is_some() {
911            return self.instance_list_legacy(definition_id, status);
912        }
913        let conn = self.conn.lock().unwrap();
914        // `definition_id` filter: match the agent's own `id` only.
915        //
916        // In the consolidated model every `is_template = 0` row IS an
917        // agent identifiable by its `id`:
918        //   - User-clone def projection: `id` = the clone def's id.
919        //   - Template-instance projection: `id` = the original inst.id.
920        //
921        // The legacy `definition_id` filter conflated "agent identity"
922        // with "parent template" because the old schema split them
923        // across two tables. The new model has no such split. A
924        // template-id filter would over-match (user-clones of X share
925        // `parent_template_id` with template-instances of X — see
926        // codex P2 on PR #1111), so we route template-id callers to
927        // an empty result. No live caller exercises that path; the
928        // only frontend consumer (`listagentinstances` RPC via
929        // swarm-model) passes empty filters.
930        let mut filter_clause = String::new();
931        let mut param_vals: Vec<String> = Vec::new();
932        if let Some(d) = definition_id {
933            filter_clause.push_str("\n               AND id = ?1");
934            param_vals.push(d.to_string());
935        }
936        // Projection for `definition_id`: use the row's own id.
937        //
938        // Reagent P2 on PR #1111 round 2: the earlier "parent_template_id
939        // with id fallback" projection diverged from legacy for
940        // user-clones derived from a template — those rows have
941        // `parent_template_id` SET (the lineage), but their legacy
942        // `db_agent_instances.definition_id` was the clone's own def
943        // id, not the template's. Template-instance projections and
944        // user-clone projections aren't schema-distinguishable in
945        // db_agents (both `is_template = 0`, `is_seeded = 0`), so any
946        // consistent projection must pick one rule. The consolidated
947        // model treats the row's `id` as the agent's identity (== legacy
948        // `definition_id` for the user-clone case), so use that. The
949        // template-instance case yields the inst.id instead of the
950        // template id, but no live caller depends on this field — it's
951        // a back-compat surface for the AgentInstance struct shape.
952        //
953        // `ORDER BY updated_at DESC`: reagent P2 on PR #1111 round 2.
954        // Continuation chains keep the head's `created_at` (the row is
955        // never re-inserted) while the dual-write bumps `updated_at` on
956        // every launch / continuation. So `created_at DESC` would rank
957        // a brand-new agent ahead of an actively-continued older one,
958        // violating "most recent activity first". `updated_at` tracks
959        // recency correctly and matches the ordering `agent_def_list`
960        // uses elsewhere in this file.
961        let mut sql = String::from(
962            "SELECT id, id AS def_id,
963                    github_context, created_at,
964                    identity_id, memory_id, instance_name, working_directory
965             FROM db_agents
966             WHERE is_template = 0
967               AND user_hidden = 0",
968        );
969        sql.push_str(&filter_clause);
970        sql.push_str("\n             ORDER BY updated_at DESC, created_at DESC");
971        let mut stmt = conn.prepare(&sql)?;
972        let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), |row| {
973            Ok(AgentInstance {
974                id: row.get(0)?,
975                definition_id: row.get(1)?,
976                parent_instance_id: String::new(),
977                block_id: String::new(),
978                session_id: String::new(),
979                status: String::new(),
980                github_context: row.get(2)?,
981                started_at: row.get(3)?,
982                ended_at: 0,
983                created_at: row.get(3)?,
984                identity_id: row.get(4)?,
985                memory_id: row.get(5)?,
986                instance_name: row.get(6)?,
987                working_directory: row.get(7)?,
988                // Filter above guarantees the row is visible; column
989                // omitted from SELECT to match. Reagent P2 on PR #1111.
990                display_hidden: false,
991            })
992        })?;
993        let mut out = Vec::new();
994        for r in iter {
995            out.push(r?);
996        }
997        Ok(out)
998    }
999
1000    /// Legacy `db_agent_instances` read — preserved for the
1001    /// status-filter case (transient state). Will retire when the
1002    /// updateagentinstance handler's fetch-and-merge pattern is
1003    /// refactored. Do NOT add new callers; use
1004    /// `instance_list` instead.
1005    fn instance_list_legacy(
1006        &self,
1007        definition_id: Option<&str>,
1008        status: Option<&str>,
1009    ) -> Result<Vec<AgentInstance>, StoreError> {
1010        let conn = self.conn.lock().unwrap();
1011        let mut sql = String::from(
1012            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1013                    status, github_context, started_at, ended_at, created_at,
1014                    identity_id, memory_id, instance_name, working_directory,
1015                    display_hidden
1016             FROM db_agent_instances",
1017        );
1018        let mut clauses: Vec<&str> = Vec::new();
1019        if definition_id.is_some() {
1020            clauses.push("definition_id = ?");
1021        }
1022        if status.is_some() {
1023            clauses.push("status = ?");
1024        }
1025        if !clauses.is_empty() {
1026            sql.push_str(" WHERE ");
1027            sql.push_str(&clauses.join(" AND "));
1028        }
1029        sql.push_str(" ORDER BY created_at DESC");
1030
1031        let mut stmt = conn.prepare(&sql)?;
1032        let mut param_vals: Vec<String> = Vec::new();
1033        if let Some(d) = definition_id {
1034            param_vals.push(d.to_string());
1035        }
1036        if let Some(s) = status {
1037            param_vals.push(s.to_string());
1038        }
1039        let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
1040        let mut out = Vec::new();
1041        for r in iter {
1042            out.push(r?);
1043        }
1044        Ok(out)
1045    }
1046
1047    pub fn instance_get(&self, id: &str) -> Result<Option<AgentInstance>, StoreError> {
1048        let conn = self.conn.lock().unwrap();
1049        let mut stmt = conn.prepare(
1050            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1051                    status, github_context, started_at, ended_at, created_at,
1052                    identity_id, memory_id, instance_name, working_directory,
1053                    display_hidden
1054             FROM db_agent_instances WHERE id = ?1",
1055        )?;
1056        let result = stmt.query_row(params![id], map_instance_row);
1057        match result {
1058            Ok(a) => Ok(Some(a)),
1059            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1060            Err(e) => Err(e.into()),
1061        }
1062    }
1063
1064    /// Insert a new instance row. Caller is responsible for the id (UUID).
1065    pub fn instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
1066        {
1067            let conn = self.conn.lock().unwrap();
1068            conn.execute(
1069                "INSERT INTO db_agent_instances
1070                    (id, definition_id, parent_instance_id, block_id, session_id, status,
1071                     github_context, started_at, ended_at, created_at,
1072                     identity_id, memory_id,
1073                     instance_name, working_directory, display_hidden)
1074                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1075                         ?13, ?14, ?15)",
1076                params![
1077                    inst.id,
1078                    inst.definition_id,
1079                    inst.parent_instance_id,
1080                    inst.block_id,
1081                    inst.session_id,
1082                    inst.status,
1083                    inst.github_context,
1084                    inst.started_at,
1085                    inst.ended_at,
1086                    inst.created_at,
1087                    inst.identity_id,
1088                    inst.memory_id,
1089                    inst.instance_name,
1090                    inst.working_directory,
1091                    if inst.display_hidden { 1_i64 } else { 0_i64 },
1092                ],
1093            )?;
1094        }
1095        self.registry_upsert_if_named(inst);
1096        // Mirror new instance into db_agents.
1097        self.agents_dual_write_instance_create(inst)?;
1098        Ok(())
1099    }
1100
1101    /// Set the `display_hidden` flag on an existing instance row. Used
1102    /// by the "Forget agent" affordance — soft-delete only; the row +
1103    /// working directory remain on disk for audit + recovery.
1104    ///
1105    /// Cross-version case: an agent migrated into the registry from
1106    /// another version's SQLite won't have a row in the current
1107    /// version's SQLite. The UPDATE returns 0 rows, but the registry
1108    /// still needs to flip — otherwise "Forget agent" silently no-ops
1109    /// on cross-version entries. Returns `true` if either side acted.
1110    pub fn instance_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
1111        let rows = {
1112            let conn = self.conn.lock().unwrap();
1113            conn.execute(
1114                "UPDATE db_agent_instances SET display_hidden = ?1 WHERE id = ?2",
1115                params![if hidden { 1_i64 } else { 0_i64 }, id],
1116            )?
1117        };
1118        let mut registry_acted = false;
1119        if let Some(reg) = self.registry() {
1120            // Only act on the registry side if a record exists there
1121            // (in either active or retired). Avoids logging spurious
1122            // "failed to retire" warnings on a no-op for unrelated ids.
1123            if reg.exists_anywhere(id) {
1124                let res = if hidden {
1125                    reg.retire(id)
1126                } else {
1127                    reg.unretire(id)
1128                };
1129                match res {
1130                    Ok(()) => registry_acted = true,
1131                    Err(e) => tracing::warn!(
1132                        instance_id = %id,
1133                        hidden,
1134                        error = %e,
1135                        "registry: failed to mirror instance_set_hidden"
1136                    ),
1137                }
1138            }
1139        }
1140        // Flip the hidden bit on db_agents.
1141        self.agents_dual_write_instance_set_hidden(id, hidden)?;
1142        Ok(rows > 0 || registry_acted)
1143    }
1144
1145    /// List named instances for the launch-modal "Continue agent"
1146    /// dropdown (`include_continuations = false`) or the picker
1147    /// "My Agents" surface (`include_continuations = true`). Filters
1148    /// to non-hidden + named rows, sorted by `started_at DESC`,
1149    /// capped by `limit`.
1150    ///
1151    /// `definition_id`, when provided, restricts the result to
1152    /// instances of that definition. Server-side filtering is
1153    /// necessary because the launch modal opens per-definition: a
1154    /// user with 200+ named agents across many definitions could
1155    /// have the current definition's older instances cut off by a
1156    /// purely global limit otherwise.
1157    ///
1158    /// `include_continuations` controls whether rows with
1159    /// `parent_instance_id != ''` (continuation chains) are
1160    /// returned:
1161    ///
1162    /// - **`false`** (legacy "head-of-chain only"). Pre-Option-E
1163    ///   semantics: hides continuation rows so the launch-modal
1164    ///   dropdown shows one entry per chain root. `listnamedagents`
1165    ///   ALSO uses this mode for its same-version SQLite enrichment
1166    ///   of registry-sourced rows — the registry mirror filter at
1167    ///   `registry_upsert_if_named` excludes continuations
1168    ///   symmetrically, and breaking that symmetry under a `limit`
1169    ///   truncation would let continuation rows displace
1170    ///   registry-head rows in the top-N and miss the
1171    ///   merge-by-id enrichment. (Codex P1 on PR #1016 first cut:
1172    ///   regresses running-state badges and focus-existing-pane
1173    ///   hints for any user whose latest instance is a continuation.)
1174    ///
1175    /// - **`true`** (Option-E "include continuations"). For the
1176    ///   picker's `listrecentsessions` flow and the
1177    ///   `template_promote` migration's instance-name lookup. Under
1178    ///   Option E the session zone is anchored on `definition_id`,
1179    ///   so a continuation row is simply the most-recent named
1180    ///   instance of an agent the user actively used — exactly
1181    ///   what those callers want visible. Excluding them hides
1182    ///   real agents (the original 2026-05-24 "Maks doesn't appear
1183    ///   under My Agents" report) and makes `template_promote`'s
1184    ///   name lookup miss the real `instance_name`, falling back
1185    ///   to the template name.
1186    pub fn instance_list_named(
1187        &self,
1188        limit: usize,
1189        definition_id: Option<&str>,
1190        identity_id: Option<&str>,
1191        include_continuations: bool,
1192    ) -> Result<Vec<AgentInstance>, StoreError> {
1193        let conn = self.conn.lock().unwrap();
1194
1195        // Dynamic bind-index assignment. We accumulate optional
1196        // string params into `extra_params` in the same order they
1197        // appear in the SQL, then bind the limit last.
1198        let mut extra_params: Vec<&str> = Vec::with_capacity(2);
1199
1200        if !include_continuations {
1201            // Legacy "dropdown" mode: only chain heads (no parent).
1202            // Used by the launch modal's `Continue agent` dropdown +
1203            // the registry-enrichment path. Symmetric with
1204            // `registry_upsert_if_named` — chains show up as one
1205            // head entry, not N entries per resume.
1206            let mut sql = String::from(
1207                "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1208                        status, github_context, started_at, ended_at, created_at,
1209                        identity_id, memory_id, instance_name, working_directory,
1210                        display_hidden
1211                 FROM db_agent_instances
1212                 WHERE display_hidden = 0
1213                   AND instance_name <> ''
1214                   AND parent_instance_id = ''",
1215            );
1216            let mut next_idx = 1usize;
1217            if let Some(id) = identity_id {
1218                sql.push_str(&format!("\n                   AND identity_id = ?{}", next_idx));
1219                extra_params.push(id);
1220                next_idx += 1;
1221            }
1222            if let Some(def) = definition_id {
1223                sql.push_str(&format!("\n                   AND definition_id = ?{}", next_idx));
1224                extra_params.push(def);
1225                next_idx += 1;
1226            }
1227            sql.push_str(&format!(
1228                "\n                 ORDER BY started_at DESC\n                 LIMIT ?{}",
1229                next_idx
1230            ));
1231            let mut stmt = conn.prepare(&sql)?;
1232            let limit_i64 = limit as i64;
1233            let mut bindings: Vec<&dyn rusqlite::ToSql> =
1234                Vec::with_capacity(extra_params.len() + 1);
1235            for s in &extra_params {
1236                bindings.push(s);
1237            }
1238            bindings.push(&limit_i64);
1239            let iter = stmt.query_map(bindings.as_slice(), map_instance_row)?;
1240            let mut out = Vec::new();
1241            for r in iter {
1242                out.push(r?);
1243            }
1244            return Ok(out);
1245        }
1246
1247        // Picker ("My Agents") mode: dedupe continuation chains so
1248        // each logical agent surfaces as exactly one row — the most
1249        // recent in its chain (by `started_at`, tiebreaker `id`).
1250        //
1251        // Before this dedup, a user with 5 continuations of one
1252        // logical agent saw 5 entries in My Agents. See discussion
1253        // #1095 / `docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md`.
1254        //
1255        // Mechanics:
1256        //   - A recursive CTE walks `parent_instance_id` from each
1257        //     head (parent_instance_id = '') down to its
1258        //     descendants, stamping every row with the head's
1259        //     `root_id`.
1260        //   - `ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY
1261        //     started_at DESC, id DESC)` picks the latest row per
1262        //     chain. The id tiebreaker keeps the ordering
1263        //     deterministic when two rows share `started_at` (only
1264        //     happens in tests / on adjacent inserts).
1265        //   - **Hidden filter must run AFTER ranking.** Otherwise
1266        //     `hidenamedagent` becomes a no-op for any chain with
1267        //     older visible siblings: the SQL excludes the hidden
1268        //     row before ranking, so the next-newest visible row
1269        //     inherits `rn = 1` and the "forgotten" agent
1270        //     immediately reappears in the picker. Codex P2 on PR
1271        //     #1096. By ranking first and filtering
1272        //     `display_hidden` last, hiding the surfaced row
1273        //     suppresses the whole chain — exactly what the user's
1274        //     forget action means.
1275        //   - The unnamed-row filter (`instance_name <> ''`) stays
1276        //     pre-rank: an unnamed continuation row should never
1277        //     win, but also shouldn't influence chain ranking — it
1278        //     simply isn't a candidate.
1279        let mut sql = String::from(
1280            r#"WITH RECURSIVE
1281            roots(id, definition_id, parent_instance_id, block_id, session_id,
1282                  status, github_context, started_at, ended_at, created_at,
1283                  identity_id, memory_id, instance_name, working_directory,
1284                  display_hidden, root_id) AS (
1285                -- Anchor: a row is its own root if it has no parent OR
1286                -- its parent no longer exists in the table. The latter
1287                -- case (orphan continuation) happens when
1288                -- `deleteagentinstance` hard-deletes a chain head —
1289                -- there's no FK cascade, so descendant rows remain. If
1290                -- we seeded only from `parent_instance_id = ''`,
1291                -- orphans would be unreachable by the recursive walk
1292                -- and disappear from My Agents even though they're
1293                -- recoverable sessions. Codex P2 on PR #1096
1294                -- bbe897cc → orphan-as-root anchor.
1295                SELECT id, definition_id, parent_instance_id, block_id, session_id,
1296                       status, github_context, started_at, ended_at, created_at,
1297                       identity_id, memory_id, instance_name, working_directory,
1298                       display_hidden,
1299                       id
1300                FROM db_agent_instances p
1301                WHERE p.parent_instance_id = ''
1302                   OR NOT EXISTS (
1303                       SELECT 1 FROM db_agent_instances q
1304                       WHERE q.id = p.parent_instance_id
1305                   )
1306                UNION ALL
1307                SELECT c.id, c.definition_id, c.parent_instance_id, c.block_id,
1308                       c.session_id, c.status, c.github_context, c.started_at,
1309                       c.ended_at, c.created_at, c.identity_id, c.memory_id,
1310                       c.instance_name, c.working_directory, c.display_hidden,
1311                       r.root_id
1312                FROM db_agent_instances c
1313                JOIN roots r ON c.parent_instance_id = r.id
1314            ),
1315            ranked AS (
1316                SELECT id, definition_id, parent_instance_id, block_id, session_id,
1317                       status, github_context, started_at, ended_at, created_at,
1318                       identity_id, memory_id, instance_name, working_directory,
1319                       display_hidden, root_id,
1320                       ROW_NUMBER() OVER (
1321                           PARTITION BY root_id
1322                           ORDER BY started_at DESC, id DESC
1323                       ) AS rn
1324                FROM roots
1325                WHERE instance_name <> ''"#
1326                .to_string(),
1327        );
1328        // Identity filter MUST run inside the `ranked` CTE (i.e.,
1329        // before ROW_NUMBER) so the newest row matching the requested
1330        // identity per chain wins. If we filtered identity in the
1331        // outer SELECT instead, a chain whose newest row uses a
1332        // different identity would be dropped even if an older row in
1333        // the chain matched. Codex P2 #3 on PR #1096 0c4c8c46.
1334        let mut next_idx = 1usize;
1335        if let Some(id) = identity_id {
1336            sql.push_str(&format!("\n                  AND identity_id = ?{}", next_idx));
1337            extra_params.push(id);
1338            next_idx += 1;
1339        }
1340        sql.push_str(
1341            r#"
1342            )
1343            SELECT id, definition_id, parent_instance_id, block_id, session_id,
1344                   status, github_context, started_at, ended_at, created_at,
1345                   identity_id, memory_id, instance_name, working_directory,
1346                   display_hidden
1347            FROM ranked
1348            WHERE rn = 1
1349              AND display_hidden = 0"#,
1350        );
1351        if let Some(def) = definition_id {
1352            sql.push_str(&format!("\n              AND definition_id = ?{}", next_idx));
1353            extra_params.push(def);
1354            next_idx += 1;
1355        }
1356        sql.push_str(&format!(
1357            "\n            ORDER BY started_at DESC\n            LIMIT ?{}",
1358            next_idx
1359        ));
1360        let mut stmt = conn.prepare(&sql)?;
1361        let limit_i64 = limit as i64;
1362        let mut bindings: Vec<&dyn rusqlite::ToSql> =
1363            Vec::with_capacity(extra_params.len() + 1);
1364        for s in &extra_params {
1365            bindings.push(s);
1366        }
1367        bindings.push(&limit_i64);
1368        let iter = stmt.query_map(bindings.as_slice(), map_instance_row)?;
1369        let mut out = Vec::new();
1370        for r in iter {
1371            out.push(r?);
1372        }
1373        Ok(out)
1374    }
1375
1376    /// Look up the canonical named-agent row matching `instance_name`.
1377    /// Used by the launch modal to detect name collisions ("did you
1378    /// mean to continue?") and by `ContinueNamedAgentCommand` to
1379    /// resolve the consolidated row when the caller only knows the
1380    /// name. Hidden rows are excluded.
1381    ///
1382    /// Reads from the consolidated `db_agents` table —
1383    /// `is_template = 0` (named user agent), `instance_name` matches,
1384    /// `user_hidden = 0`. Continuation chains are pre-collapsed in
1385    /// `db_agents` (one row per logical agent), so this returns the
1386    /// canonical agent regardless of how many launches its chain has.
1387    /// MRU tiebreak is by `updated_at` (the dual-write touches it on
1388    /// every continuation), then `created_at` for stable order.
1389    ///
1390    /// The legacy `db_agent_instances` carried per-launch runtime
1391    /// state (`block_id`, `session_id`, `status`, `started_at`,
1392    /// `ended_at`, `parent_instance_id`) that has no analog in
1393    /// `db_agents` — those fields are returned as their `AgentInstance`
1394    /// defaults (empty strings, 0). Callers wanting transient state
1395    /// should consult runtime sources (the controller, the block
1396    /// row); none of the documented use cases need it (collision
1397    /// detection only cares about identity / cwd; ContinueNamed only
1398    /// cares about `id` + bindings).
1399    /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.
1400    pub fn instance_get_by_name(
1401        &self,
1402        instance_name: &str,
1403    ) -> Result<Option<AgentInstance>, StoreError> {
1404        if instance_name.is_empty() {
1405            return Ok(None);
1406        }
1407        let conn = self.conn.lock().unwrap();
1408        // `definition_id` projection: use the row's own `id`.
1409        //
1410        // The earlier "parent_template_id with id fallback" rule
1411        // misrendered the legacy semantics for user-clones derived
1412        // from a template — those rows have `parent_template_id` SET
1413        // (lineage record) but their legacy `definition_id` was the
1414        // clone's own def id, NOT the template's. Template-instance
1415        // and user-clone projections aren't schema-distinguishable in
1416        // db_agents, so any consistent rule must pick one. The
1417        // consolidated model treats `id` as the agent's identity, so
1418        // that's what we expose — matches `instance_list` (3b.3a)
1419        // after the same fix. Reagent P2 on PR #1111 round 2.
1420        let mut stmt = conn.prepare(
1421            "SELECT id, id AS def_id,
1422                    github_context, created_at,
1423                    identity_id, memory_id, instance_name, working_directory,
1424                    user_hidden
1425             FROM db_agents
1426             WHERE instance_name = ?1
1427               AND is_template = 0
1428               AND user_hidden = 0
1429             ORDER BY updated_at DESC, created_at DESC
1430             LIMIT 1",
1431        )?;
1432        let result = stmt.query_row(params![instance_name], |row| {
1433            Ok(AgentInstance {
1434                id: row.get(0)?,
1435                definition_id: row.get(1)?,
1436                parent_instance_id: String::new(),
1437                block_id: String::new(),
1438                session_id: String::new(),
1439                status: String::new(),
1440                github_context: row.get(2)?,
1441                started_at: row.get(3)?, // created_at — best proxy for the consolidated row
1442                ended_at: 0,
1443                created_at: row.get(3)?,
1444                identity_id: row.get(4)?,
1445                memory_id: row.get(5)?,
1446                instance_name: row.get(6)?,
1447                working_directory: row.get(7)?,
1448                display_hidden: row.get::<_, i64>(8)? != 0,
1449            })
1450        });
1451        match result {
1452            Ok(a) => Ok(Some(a)),
1453            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1454            Err(e) => Err(e.into()),
1455        }
1456    }
1457
1458    /// Partial update of an instance's mutable runtime fields. Only
1459    /// `Some` fields are written; `None` leaves that column untouched.
1460    ///
1461    /// Replaces the `updateagentinstance` handler's fetch-and-merge
1462    /// (read the full row → fill the unspecified fields → write the
1463    /// whole struct back). That read was the only production caller
1464    /// that needed `instance_get`'s transient per-launch fields, which
1465    /// pinned `instance_get` to the legacy `db_agent_instances` table.
1466    /// With a partial write the handler no longer reads the row at all.
1467    /// See docs/specs/SPEC_UPDATEAGENTINSTANCE_PARTIAL_UPDATE_2026_05_29.md.
1468    ///
1469    /// Returns the post-update row so callers that need `definition_id`
1470    /// for an event scope — or want to echo the row back — get it from
1471    /// the same authoritative reload this method already runs to refresh
1472    /// the registry mirror + dual-write. Those consumers read
1473    /// only non-transient fields, so the reload survives a future
1474    /// `instance_get` → db_agents flip.
1475    ///
1476    /// `None` is reserved for **not-found** (the id doesn't exist). An
1477    /// all-`None` update on an existing id is a no-op that returns the
1478    /// unchanged row — so callers can distinguish "nothing to change"
1479    /// from "no such instance".
1480    pub fn instance_update_partial(
1481        &self,
1482        id: &str,
1483        upd: &InstanceUpdate,
1484    ) -> Result<Option<AgentInstance>, StoreError> {
1485        use rusqlite::types::ToSql;
1486
1487        let mut sets: Vec<&str> = Vec::new();
1488        let mut vals: Vec<Box<dyn ToSql>> = Vec::new();
1489        if let Some(v) = &upd.block_id {
1490            sets.push("block_id = ?");
1491            vals.push(Box::new(v.clone()));
1492        }
1493        if let Some(v) = &upd.session_id {
1494            sets.push("session_id = ?");
1495            vals.push(Box::new(v.clone()));
1496        }
1497        if let Some(v) = &upd.status {
1498            sets.push("status = ?");
1499            vals.push(Box::new(v.clone()));
1500        }
1501        if let Some(v) = &upd.github_context {
1502            // `Some("")` explicitly clears (matches the command contract).
1503            sets.push("github_context = ?");
1504            vals.push(Box::new(v.clone()));
1505        }
1506        if let Some(v) = upd.ended_at {
1507            sets.push("ended_at = ?");
1508            vals.push(Box::new(v));
1509        }
1510        if sets.is_empty() {
1511            // No fields to write. Return the current row unchanged (or
1512            // `None` if the id genuinely doesn't exist) so the caller can
1513            // still tell a no-op apart from not-found — rather than
1514            // conflating both as `None`. No write, no registry/dual-write
1515            // reload needed since nothing changed.
1516            return self.instance_get(id);
1517        }
1518
1519        let rows = {
1520            let conn = self.conn.lock().unwrap();
1521            let sql = format!(
1522                "UPDATE db_agent_instances SET {} WHERE id = ?",
1523                sets.join(", ")
1524            );
1525            vals.push(Box::new(id.to_string()));
1526            let params: Vec<&dyn ToSql> = vals.iter().map(|b| b.as_ref()).collect();
1527            conn.execute(&sql, params.as_slice())?
1528        };
1529        if rows == 0 {
1530            return Ok(None);
1531        }
1532        // Reload the authoritative post-update row and refresh the
1533        // registry mirror + dual-write — identical to `instance_update`.
1534        let fresh = self.instance_get(id)?;
1535        if let Some(f) = &fresh {
1536            self.registry_upsert_if_named(f);
1537            // Mirror update to db_agents.
1538            self.agents_dual_write_instance_update(f)?;
1539        }
1540        Ok(fresh)
1541    }
1542
1543    /// Update mutable instance fields. `id`, `definition_id`,
1544    /// `parent_instance_id`, `started_at`, `created_at` are immutable
1545    /// after insert (they describe provenance, not state).
1546    ///
1547    /// Retained as a full-struct convenience for store tests + internal
1548    /// callers; the `updateagentinstance` handler now uses
1549    /// [`Self::instance_update_partial`] so it no longer reads the row
1550    /// to merge.
1551    pub fn instance_update(&self, inst: &AgentInstance) -> Result<bool, StoreError> {
1552        let rows = {
1553            let conn = self.conn.lock().unwrap();
1554            conn.execute(
1555                "UPDATE db_agent_instances SET
1556                    block_id = ?1,
1557                    session_id = ?2,
1558                    status = ?3,
1559                    github_context = ?4,
1560                    ended_at = ?5
1561                 WHERE id = ?6",
1562                params![
1563                    inst.block_id,
1564                    inst.session_id,
1565                    inst.status,
1566                    inst.github_context,
1567                    inst.ended_at,
1568                    inst.id,
1569                ],
1570            )?
1571        };
1572        if rows > 0 {
1573            // Refresh the registry from the post-update authoritative row.
1574            // `inst` is the caller's pre-update view; SQL UPDATE only
1575            // touches a subset of fields, so we reload to keep registry
1576            // mirror exact.
1577            if let Ok(Some(fresh)) = self.instance_get(&inst.id) {
1578                self.registry_upsert_if_named(&fresh);
1579                // Mirror status fields to db_agents.
1580                self.agents_dual_write_instance_update(&fresh)?;
1581            }
1582        }
1583        Ok(rows > 0)
1584    }
1585
1586    /// Repoint every instance currently referencing `old_def_id` to
1587    /// `new_def_id`. Used by the Phase 1 two-tier-picker migration
1588    /// (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md): when a seeded
1589    /// template has been used directly (carries an `agent:<id>:current`
1590    /// zone), the migration clones the template into a user agent and
1591    /// repoints any instances so the existing reattach flow
1592    /// (`continueOfInstanceId`) keeps working against the new
1593    /// definition_id. Returns the number of rows updated.
1594    ///
1595    /// `definition_id` is declared immutable post-insert on the normal
1596    /// `instance_update` path. This is the migration escape hatch.
1597    pub fn instance_repoint_definition(
1598        &self,
1599        old_def_id: &str,
1600        new_def_id: &str,
1601    ) -> Result<usize, StoreError> {
1602        let rows = {
1603            let conn = self.conn.lock().unwrap();
1604            conn.execute(
1605                "UPDATE db_agent_instances SET definition_id = ?1 WHERE definition_id = ?2",
1606                params![new_def_id, old_def_id],
1607            )?
1608        };
1609        // Re-aim parent_template_id on user-clone projection rows in db_agents.
1610        if rows > 0 {
1611            self.agents_dual_write_instance_repoint(old_def_id, new_def_id)?;
1612        }
1613        Ok(rows)
1614    }
1615
1616    pub fn instance_delete(&self, id: &str) -> Result<bool, StoreError> {
1617        let rows = {
1618            let conn = self.conn.lock().unwrap();
1619            conn.execute("DELETE FROM db_agent_instances WHERE id = ?1", params![id])?
1620        };
1621        if rows > 0 {
1622            if let Some(reg) = self.registry() {
1623                if let Err(e) = reg.hard_delete(id) {
1624                    tracing::warn!(
1625                        instance_id = %id,
1626                        error = %e,
1627                        "registry: failed to mirror instance_delete"
1628                    );
1629                }
1630            }
1631            // Drop the user-clone projection from db_agents.
1632            self.agents_dual_write_instance_delete(id)?;
1633        }
1634        Ok(rows > 0)
1635    }
1636
1637    /// Back-fill `db_agent_instances.identity_id` for legacy rows that
1638    /// have either the empty string (post-v7 default before the launch
1639    /// modal required Identity) or the literal `"blank"` sentinel
1640    /// (pre-v8 placeholder for "use ambient creds"). Both shapes map
1641    /// to "no Identity bundle assigned" and the OAuth-bundles startup
1642    /// migration (PR E, spec §5) routes them to the newly-seeded
1643    /// Default bundle so the resolver can inject env vars from the
1644    /// captured ambient credentials at the next spawn.
1645    ///
1646    /// Returns the number of rows touched. Caller must verify that
1647    /// `new_identity_id` is a real `db_identity_bundles.id` — this
1648    /// method does NOT enforce FK validity (the column has no FK
1649    /// constraint per the v7 migration). Mis-use would orphan the
1650    /// rows to a non-existent bundle; the OAuth-bundles migration
1651    /// guards against this by only calling here when it just upserted
1652    /// the bundle row.
1653    pub fn instance_backfill_identity_id(
1654        &self,
1655        new_identity_id: &str,
1656    ) -> Result<usize, StoreError> {
1657        let rows = {
1658            let conn = self.conn.lock().unwrap();
1659            conn.execute(
1660                "UPDATE db_agent_instances
1661                 SET identity_id = ?1
1662                 WHERE identity_id = '' OR identity_id = 'blank'",
1663                params![new_identity_id],
1664            )?
1665        };
1666        // Backfill the same identity_id on db_agents user-clone rows.
1667        self.agents_dual_write_backfill_identity(new_identity_id)?;
1668        Ok(rows)
1669    }
1670
1671    /// Resolve the agent bindings tied to a block.
1672    ///
1673    /// Resolve through `block.meta.agentId` (or legacy
1674    /// `agent:id`) against `db_agents` for the user-clone case;
1675    /// fall back to the legacy `db_agent_instances` lookup by
1676    /// `block_id` for seeded-template launches and any other block
1677    /// the consolidated path can't satisfy.
1678    ///
1679    /// We deliberately do NOT consult `block.meta.agentInstanceId`:
1680    /// codex P1 on PR #1114 round 3 surfaced that pane reuse
1681    /// (`backToPicker` clears `agentId` but not `agentInstanceId`)
1682    /// and quick-launch (no instance-id stamp) leave the key stale.
1683    /// Trusting it would silently bleed the prior agent's identity
1684    /// across reopens — exactly the regression the legacy active-
1685    /// instance query avoided. The agentId-then-legacy path covers
1686    /// every launch shape without needing instance-id stamping:
1687    ///   - User-clone: db_agents.id == def.id, hits is_template=0.
1688    ///   - Template direct-launch: agentId points at a template
1689    ///     (is_template=1, filtered out). Legacy fallback finds the
1690    ///     active instance row keyed on block_id.
1691    ///   - Pane reuse: stale `agentInstanceId` is ignored; current
1692    ///     `agentId` wins.
1693    ///
1694    /// Replaces the legacy "find most recent active instance for
1695    /// this block" as the PRIMARY path for user-clones; the legacy
1696    /// query remains as fallback for templates + edge cases.
1697    /// Retires fully when Phase 3c drops the legacy table.
1698    /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.
1699    ///
1700    /// `user_hidden` is NOT filtered — hiding a named agent
1701    /// ("forget") is a picker-visibility concept; the pane bound to
1702    /// that agent must keep resolving credentials. Codex P2 on PR
1703    /// #1114 round 2.
1704    ///
1705    /// Used by the identity resolver to pull `identity_id` /
1706    /// `memory_id` for environment injection on every command
1707    /// dispatch. Caller only reads `identity_id` from the returned
1708    /// `AgentInstance` — transient per-launch fields (status,
1709    /// session_id, started_at, ended_at, parent_instance_id) come
1710    /// back as type defaults. `block_id` echoes back the caller's
1711    /// argument.
1712    pub fn instance_get_active_for_block(
1713        &self,
1714        block_id: &str,
1715    ) -> Result<Option<AgentInstance>, StoreError> {
1716        let block: crate::backend::obj::Block = match self.get(block_id)? {
1717            Some(b) => b,
1718            None => return Ok(None),
1719        };
1720        let agent_id = block
1721            .meta
1722            .get("agentId")
1723            .and_then(|v| v.as_str())
1724            .or_else(|| block.meta.get("agent:id").and_then(|v| v.as_str()))
1725            .unwrap_or("")
1726            .to_string();
1727        let conn = self.conn.lock().unwrap();
1728        if !agent_id.is_empty() {
1729            let mut stmt = conn.prepare(
1730                "SELECT id, github_context, created_at,
1731                        identity_id, memory_id, instance_name, working_directory
1732                 FROM db_agents
1733                 WHERE id = ?1
1734                   AND is_template = 0",
1735            )?;
1736            let block_id_owned = block_id.to_string();
1737            let result = stmt.query_row(params![agent_id], |row| {
1738                Ok(AgentInstance {
1739                    id: row.get(0)?,
1740                    definition_id: row.get(0)?, // consolidated model — see 3b.3a
1741                    parent_instance_id: String::new(),
1742                    block_id: block_id_owned.clone(),
1743                    session_id: String::new(),
1744                    status: String::new(),
1745                    github_context: row.get(1)?,
1746                    started_at: row.get(2)?,
1747                    ended_at: 0,
1748                    created_at: row.get(2)?,
1749                    identity_id: row.get(3)?,
1750                    memory_id: row.get(4)?,
1751                    instance_name: row.get(5)?,
1752                    working_directory: row.get(6)?,
1753                    display_hidden: false,
1754                })
1755            });
1756            match result {
1757                Ok(a) => return Ok(Some(a)),
1758                Err(rusqlite::Error::QueryReturnedNoRows) => {}
1759                Err(e) => return Err(e.into()),
1760            }
1761        }
1762        // Legacy-block fallback: seeded-template launches and any
1763        // block whose agentId references a row that doesn't exist
1764        // in the consolidated view. The active-instance query is
1765        // robust to pane reuse (it picks the most recent active
1766        // row keyed on block_id).
1767        let mut legacy_stmt = conn.prepare(
1768            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1769                    status, github_context, started_at, ended_at, created_at,
1770                    identity_id, memory_id, instance_name, working_directory,
1771                    display_hidden
1772             FROM db_agent_instances
1773             WHERE block_id = ?1 AND status IN ('running', 'paused')
1774             ORDER BY created_at DESC
1775             LIMIT 1",
1776        )?;
1777        match legacy_stmt.query_row(params![block_id], map_instance_row) {
1778            Ok(a) => Ok(Some(a)),
1779            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1780            Err(e) => Err(e.into()),
1781        }
1782    }
1783
1784    // Dual-write helpers live in `super::dual_write`.
1785}
1786
1787fn map_instance_row(row: &rusqlite::Row) -> rusqlite::Result<AgentInstance> {
1788    let display_hidden_int: i64 = row.get(14)?;
1789    Ok(AgentInstance {
1790        id: row.get(0)?,
1791        definition_id: row.get(1)?,
1792        parent_instance_id: row.get(2)?,
1793        block_id: row.get(3)?,
1794        session_id: row.get(4)?,
1795        status: row.get(5)?,
1796        github_context: row.get(6)?,
1797        started_at: row.get(7)?,
1798        ended_at: row.get(8)?,
1799        created_at: row.get(9)?,
1800        identity_id: row.get(10)?,
1801        memory_id: row.get(11)?,
1802        instance_name: row.get(12)?,
1803        working_directory: row.get(13)?,
1804        display_hidden: display_hidden_int != 0,
1805    })
1806}
1807
1808/// Row mapper for `db_agents` rows projected back into the
1809/// `AgentDefinition` shape. The column order MUST match the SELECT in
1810/// `agent_def_list`. `parent_template_id` maps to `parent_id` because
1811/// the consolidated table renamed the field to clarify its semantics
1812/// (template lineage), but the wire shape kept the old name.
1813fn map_agent_definition_row(row: &rusqlite::Row) -> rusqlite::Result<AgentDefinition> {
1814    Ok(AgentDefinition {
1815        id: row.get(0)?,
1816        slug: row.get(1)?,
1817        name: row.get(2)?,
1818        icon: row.get(3)?,
1819        provider: row.get(4)?,
1820        description: row.get(5)?,
1821        working_directory: row.get(6)?,
1822        shell: row.get(7)?,
1823        provider_flags: row.get(8)?,
1824        auto_start: row.get(9)?,
1825        restart_on_crash: row.get(10)?,
1826        idle_timeout_minutes: row.get(11)?,
1827        created_at: row.get(12)?,
1828        agent_type: row.get(13)?,
1829        environment: row.get(14)?,
1830        agent_bus_id: row.get(15)?,
1831        is_seeded: row.get(16)?,
1832        accounts: row.get(17)?,
1833        parent_id: row.get(18)?,
1834        branch_label: row.get(19)?,
1835        updated_at: row.get(20)?,
1836        user_hidden: row.get(21)?,
1837        container_image: row.get(22)?,
1838        container_volumes: row.get(23)?,
1839        container_name: row.get(24)?,
1840    })
1841}